Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit f27bf6409a2cc2ced8b42f6a7e2b4705128e53cc


Parents : 8fab427
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:51:24-05:00

feat(plugin): add plugin failure reporting API and enhance plugin security validation

Changes

35 files changed, 2611 insertions(+), 1648 deletions(-)


Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index ef7940d4..908f0eba 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -149,6 +149,7 @@ from meshchatx.src.backend.nomadnet_utils import (
)
from meshchatx.src.backend.page_node_manager import PageNodeManager
from meshchatx.src.backend.plugin_manager import PluginManager
+from meshchatx.src.backend.plugin_guard import PluginSecurityError
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
from meshchatx.src.backend.app_security_settings import (
get_web_ui_ip_allowlist,
@@ -11053,6 +11054,25 @@ class ReticulumMeshChat:
except KeyError:
return web.json_response({"message": "Plugin not found"}, status=404)
+ @routes.post("/api/v1/plugins/{plugin_id}/report-failure")
+ async def plugins_report_failure(request):
+ plugin_id = request.match_info["plugin_id"]
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ reason = data.get("reason") or "Unknown plugin failure"
+ source = data.get("source") or "frontend"
+ try:
+ plugin = await asyncio.to_thread(
+ self.plugin_manager.report_failure, plugin_id, reason, source
+ )
+ if plugin is None:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+ return web.json_response(plugin)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
@routes.post("/api/v1/plugins/{plugin_id}/invoke")
async def plugins_invoke(request):
plugin_id = request.match_info["plugin_id"]
@@ -11086,6 +11106,8 @@ class ReticulumMeshChat:
return web.json_response({"message": "Plugin not found"}, status=404)
except FileNotFoundError:
return web.json_response({"message": "Asset not found"}, status=404)
+ except PluginSecurityError as e:
+ return web.json_response({"message": str(e)}, status=400)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
return web.FileResponse(path)

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js b/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
new file mode 100644
index 00000000..64363721
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
@@ -0,0 +1,196 @@
+const MAX_ANNOUNCES = 80;
+
+/**
+ * @param {{ t: (key: string) => string }} api
+ * @param {string} key
+ * @param {Record<string, string | number>} [params]
+ */
+function formatLabel(api, key, params = {}) {
+ let text = api.t(key);
+ for (const [name, value] of Object.entries(params)) {
+ text = text.replace(`{${name}}`, String(value));
+ }
+ return text;
+}
+
+function shortHash(hash) {
+ if (!hash || hash.length < 12) {
+ return hash || "—";
+ }
+ return `${hash.slice(0, 10)}…${hash.slice(-6)}`;
+}
+
+function hopLabel(api, hops) {
+ if (hops == null) {
+ return formatLabel(api, "hops_unknown");
+ }
+ if (hops === 1) {
+ return formatLabel(api, "hops_one");
+ }
+ return formatLabel(api, "hops_many", { count: hops });
+}
+
+/**
+ * @param {{ t: (key: string) => string, invoke: Function, setUi: Function, onAction: Function, onEvent: Function, onRefresh: Function, getInputValue: Function }} api
+ */
+export async function activate(api) {
+ /** @type {Array<Record<string, string>>} */
+ let announces = [];
+ /** @type {{ paths: Array<Record<string, unknown>>, total: number, responsive: number, unresponsive: number }} */
+ let pathData = { paths: [], total: 0, responsive: 0, unresponsive: 0 };
+
+ function stateLabel(state) {
+ if (state === 1) {
+ return formatLabel(api, "state_responsive");
+ }
+ if (state === 2) {
+ return formatLabel(api, "state_unresponsive");
+ }
+ return formatLabel(api, "state_unknown");
+ }
+
+ async function refreshPaths() {
+ const search = (api.getInputValue("path-search") || "").trim();
+ pathData = await api.invoke("readPaths", {
+ search: search || undefined,
+ limit: 150,
+ });
+ }
+
+ function render() {
+ const announceFilter = (api.getInputValue("announce-filter") || "").trim().toLowerCase();
+ const filteredAnnounces = announces.filter((entry) => {
+ if (!announceFilter) {
+ return true;
+ }
+ const haystack = `${entry.aspect || ""} ${entry.destination_hash || ""} ${entry.app_data || ""}`.toLowerCase();
+ return haystack.includes(announceFilter);
+ });
+
+ api.setUi({
+ type: "column",
+ children: [
+ {
+ type: "text",
+ variant: "title",
+ value: formatLabel(api, "title"),
+ },
+ {
+ type: "text",
+ value: formatLabel(api, "description"),
+ },
+ {
+ type: "text",
+ variant: "title",
+ value: formatLabel(api, "announces_section"),
+ },
+ {
+ type: "text",
+ value: formatLabel(api, "announce_stats", {
+ shown: Math.min(filteredAnnounces.length, 40),
+ total: announces.length,
+ }),
+ },
+ {
+ type: "input",
+ id: "announce-filter",
+ label: formatLabel(api, "filter"),
+ placeholder: formatLabel(api, "filter_placeholder"),
+ },
+ {
+ type: "button",
+ id: "refresh",
+ label: formatLabel(api, "refresh"),
+ },
+ {
+ type: "button",
+ id: "clear-announces",
+ label: formatLabel(api, "clear_feed"),
+ },
+ {
+ type: "list",
+ emptyText: formatLabel(api, "no_announces"),
+ items: filteredAnnounces.slice(0, 40).map((entry) => ({
+ type: "row",
+ children: [
+ { type: "text", variant: "mono", value: entry.receivedAt || "—" },
+ { type: "text", value: entry.aspect || "—" },
+ { type: "text", variant: "mono", value: shortHash(entry.destination_hash) },
+ {
+ type: "text",
+ value: (entry.app_data || "").slice(0, 56) || "—",
+ },
+ ],
+ })),
+ },
+ {
+ type: "text",
+ variant: "title",
+ value: formatLabel(api, "paths_section"),
+ },
+ {
+ type: "text",
+ value: formatLabel(api, "path_stats", {
+ total: pathData.total || 0,
+ responsive: pathData.responsive || 0,
+ unresponsive: pathData.unresponsive || 0,
+ }),
+ },
+ {
+ type: "input",
+ id: "path-search",
+ label: formatLabel(api, "path_search"),
+ placeholder: formatLabel(api, "path_search_placeholder"),
+ },
+ {
+ type: "list",
+ emptyText: formatLabel(api, "no_paths"),
+ items: (pathData.paths || []).map((entry) => ({
+ type: "row",
+ children: [
+ {
+ type: "text",
+ variant: "mono",
+ value: shortHash(entry.destination_hash),
+ },
+ { type: "text", value: hopLabel(api, entry.hops) },
+ { type: "text", value: entry.interface || "—" },
+ { type: "text", value: stateLabel(entry.state) },
+ ],
+ })),
+ },
+ ],
+ });
+ }
+
+ async function refresh() {
+ await refreshPaths();
+ render();
+ }
+
+ api.onAction(async (actionId) => {
+ if (actionId === "refresh") {
+ await refresh();
+ } else if (actionId === "clear-announces") {
+ announces = [];
+ render();
+ }
+ });
+
+ api.onEvent("announce.received", async (payload) => {
+ announces.unshift({
+ aspect: payload?.aspect || "",
+ destination_hash: payload?.destination_hash || "",
+ app_data: payload?.app_data || "",
+ receivedAt: new Date().toLocaleTimeString(),
+ });
+ if (announces.length > MAX_ANNOUNCES) {
+ announces = announces.slice(0, MAX_ANNOUNCES);
+ }
+ await refreshPaths();
+ render();
+ });
+
+ api.onRefresh(refresh);
+ await refresh();
+}

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json b/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
new file mode 100644
index 00000000..dcf1d3d6
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
@@ -0,0 +1,23 @@
+{
+ "nav": "Mesh Observatory",
+ "title": "Mesh Observatory",
+ "description": "Watch live announces and browse your Reticulum path table in one place.",
+ "announces_section": "Live announces",
+ "announce_stats": "Showing {shown} of {total} captured announces",
+ "filter": "Filter announces",
+ "filter_placeholder": "Aspect, hash, or app data",
+ "refresh": "Refresh paths",
+ "clear_feed": "Clear announce feed",
+ "no_announces": "No announces captured yet. Activity will appear here as the mesh announces.",
+ "paths_section": "Path table",
+ "path_stats": "{total} routes — {responsive} responsive, {unresponsive} unresponsive",
+ "path_search": "Search paths",
+ "path_search_placeholder": "Destination or via hash",
+ "no_paths": "No paths match your search.",
+ "hops_unknown": "Unknown hops",
+ "hops_one": "1 hop",
+ "hops_many": "{count} hops",
+ "state_responsive": "Responsive",
+ "state_unresponsive": "Unresponsive",
+ "state_unknown": "Unknown"
+}

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json b/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
new file mode 100644
index 00000000..ffe1cb61
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
@@ -0,0 +1,41 @@
+{
+ "id": "com.meshchatx.mesh-observatory",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Mesh Observatory",
+ "description": "Live announce feed and searchable path table for your mesh.",
+ "frontend": {
+ "entry": "frontend/main.js",
+ "type": "js"
+ },
+ "i18n": {
+ "directory": "locales",
+ "defaultLocale": "en"
+ },
+ "contributes": {
+ "navItems": [
+ {
+ "id": "mesh-observatory",
+ "route": { "name": "plugin-mesh-observatory" },
+ "icon": "chart-line",
+ "labelKey": "nav"
+ }
+ ],
+ "toolsPageEntries": [
+ {
+ "name": "mesh-observatory",
+ "route": { "name": "plugin-mesh-observatory" },
+ "icon": "chart-line",
+ "iconBg": "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
+ "titleKey": "title",
+ "descriptionKey": "description"
+ }
+ ]
+ },
+ "permissions": {
+ "hooks": ["announce.received"],
+ "managers": ["destinationPath.read"],
+ "storage": "isolated",
+ "network": "none"
+ }
+}

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
index 59bbdfc0..e506d17b 100644
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
@@ -13,22 +13,22 @@ export async function activate(api) {
{
type: "text",
variant: "title",
- value: api.t("plugins.transport_node_monitor.title"),
+ value: api.t("title"),
},
{
type: "text",
- value: api.t("plugins.transport_node_monitor.description"),
+ value: api.t("description"),
},
{
type: "input",
id: "watch-hash",
- label: api.t("plugins.transport_node_monitor.watch_hash"),
- placeholder: api.t("plugins.transport_node_monitor.watch_hash_placeholder"),
+ label: api.t("watch_hash"),
+ placeholder: api.t("watch_hash_placeholder"),
},
{
type: "button",
id: "add-watch",
- label: api.t("plugins.transport_node_monitor.add_watch"),
+ label: api.t("add_watch"),
},
{
type: "list",
@@ -40,7 +40,7 @@ export async function activate(api) {
type: "text",
value:
paths.find((entry) => entry.destination_hash === hash)?.hops?.toString() ??
- api.t("plugins.transport_node_monitor.no_path"),
+ api.t("no_path"),
},
],
})),

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json b/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json
new file mode 100644
index 00000000..19610822
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json
@@ -0,0 +1,9 @@
+{
+ "nav": "Transport Nodes",
+ "title": "Transport Node Monitor",
+ "description": "Watch transport node destinations and path hop counts.",
+ "watch_hash": "Destination hash",
+ "watch_hash_placeholder": "Enter a destination hash to watch",
+ "add_watch": "Add watch",
+ "no_path": "No path"
+}

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json b/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
index 02561019..5e26f839 100644
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
@@ -1,16 +1,16 @@
{
- "id": "com.meshchatx.transport-node-monitor",
- "version": "1.0.0",
- "apiVersion": 1,
- "name": "Transport Node Monitor",
- "description": "Track watched transport nodes, path hops, and announce activity.",
+ "id": "com.meshchatx.transport-node-monitor",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Transport Node Monitor",
+ "description": "Track watched transport nodes, path hops, and announce activity.",
"frontend": {
"entry": "frontend/main.js",
"type": "js"
},
- "backend": {
- "entry": "backend/plugin.wasm",
- "type": "wasm"
+ "i18n": {
+ "directory": "locales",
+ "defaultLocale": "en"
},
"contributes": {
"navItems": [
@@ -18,7 +18,7 @@
"id": "transport-node-monitor",
"route": { "name": "plugin-transport-node-monitor" },
"icon": "router-wireless",
- "labelKey": "plugins.transport_node_monitor.nav"
+ "labelKey": "nav"
}
],
"toolsPageEntries": [
@@ -27,21 +27,21 @@
"route": { "name": "plugin-transport-node-monitor" },
"icon": "router-wireless",
"iconBg": "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
- "titleKey": "plugins.transport_node_monitor.title",
- "descriptionKey": "plugins.transport_node_monitor.description"
+ "titleKey": "title",
+ "descriptionKey": "description"
}
],
- "settingsSections": [
- {
- "id": "plugins",
- "tab": "maintenance"
- }
- ]
- },
- "permissions": {
- "hooks": ["announce.received"],
- "managers": ["destinationPath.read"],
- "storage": "isolated",
- "network": "none"
- }
+ "settingsSections": [
+ {
+ "id": "plugins",
+ "tab": "maintenance"
+ }
+ ]
+ },
+ "permissions": {
+ "hooks": ["announce.received"],
+ "managers": ["destinationPath.read"],
+ "storage": "isolated",
+ "network": "none"
+ }
}

diff --git a/meshchatx/src/backend/plugin_guard.py b/meshchatx/src/backend/plugin_guard.py
new file mode 100644
index 00000000..15a00ecd
--- /dev/null
+++ b/meshchatx/src/backend/plugin_guard.py
@@ -0,0 +1,97 @@
+# SPDX-License-Identifier: 0BSD
+
+from __future__ import annotations
+
+import os
+import zipfile
+
+MAX_PLUGIN_ZIP_BYTES = 20 * 1024 * 1024
+MAX_EXTRACT_BYTES = 50 * 1024 * 1024
+MAX_EXTRACT_FILES = 256
+MAX_WASM_BYTES = 10 * 1024 * 1024
+MAX_INVOKE_PAYLOAD_BYTES = 65_536
+PLUGIN_ERROR_BUDGET = 5
+PLUGIN_ERROR_WINDOW_SECONDS = 300
+
+
+class PluginSecurityError(ValueError):
+ """Raised when a plugin archive or asset fails validation."""
+
+
+def normalize_asset_path(asset_name: str) -> str:
+ normalized = os.path.normpath(asset_name).replace("\\", "/")
+ if not normalized or normalized in {".", ".."}:
+ raise PluginSecurityError("invalid asset path")
+ if normalized.startswith("../") or normalized.startswith("/"):
+ raise PluginSecurityError("invalid asset path")
+ if "/../" in f"/{normalized}/":
+ raise PluginSecurityError("invalid asset path")
+ return normalized
+
+
+def validate_wasm_file(path: str) -> None:
+ if not os.path.isfile(path):
+ raise PluginSecurityError("backend wasm entry not found")
+ size = os.path.getsize(path)
+ if size <= 0:
+ raise PluginSecurityError("backend wasm entry is empty")
+ if size > MAX_WASM_BYTES:
+ raise PluginSecurityError("backend wasm entry is too large")
+
+
+def validate_invoke_payload(payload: bytes) -> None:
+ if len(payload) > MAX_INVOKE_PAYLOAD_BYTES:
+ raise PluginSecurityError("plugin invoke payload is too large")
+
+
+def _zip_entry_is_safe(name: str) -> bool:
+ normalized = os.path.normpath(name).replace("\\", "/")
+ if normalized.startswith("../") or normalized.startswith("/"):
+ return False
+ if normalized in {"", ".", ".."}:
+ return False
+ return True
+
+
+def safe_extract_zip(zip_path: str, extract_dir: str) -> str:
+ total_bytes = 0
+ file_count = 0
+ with zipfile.ZipFile(zip_path) as archive:
+ for info in archive.infolist():
+ if info.is_dir():
+ continue
+ if not _zip_entry_is_safe(info.filename):
+ raise PluginSecurityError("plugin archive contains unsafe paths")
+ total_bytes += info.file_size
+ file_count += 1
+ if file_count > MAX_EXTRACT_FILES:
+ raise PluginSecurityError("plugin archive contains too many files")
+ if total_bytes > MAX_EXTRACT_BYTES:
+ raise PluginSecurityError("plugin archive is too large when extracted")
+ archive.extractall(extract_dir)
+ return resolve_plugin_root(extract_dir)
+
+
+def resolve_plugin_root(extract_dir: str) -> str:
+ manifest_path = os.path.join(extract_dir, "plugin.json")
+ if os.path.isfile(manifest_path):
+ return extract_dir
+ children = [
+ name
+ for name in os.listdir(extract_dir)
+ if os.path.isdir(os.path.join(extract_dir, name))
+ ]
+ if len(children) == 1:
+ candidate = os.path.join(extract_dir, children[0])
+ if os.path.isfile(os.path.join(candidate, "plugin.json")):
+ return candidate
+ raise PluginSecurityError("plugin.json not found in archive")
+
+
+def validate_zip_bytes(payload: bytes) -> None:
+ if not payload:
+ raise PluginSecurityError("empty plugin archive")
+ if len(payload) > MAX_PLUGIN_ZIP_BYTES:
+ raise PluginSecurityError("plugin archive is too large")
+ if not payload.startswith(b"PK"):
+ raise PluginSecurityError("plugin archive is not a zip file")

diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index 7408b69e..f6a2e838 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -8,10 +8,21 @@ import re
import shutil
import sqlite3
import threading
-import zipfile
+import time
from dataclasses import dataclass, field
from typing import Any
+from meshchatx.src.backend.plugin_guard import (
+ PLUGIN_ERROR_BUDGET,
+ PLUGIN_ERROR_WINDOW_SECONDS,
+ PluginSecurityError,
+ normalize_asset_path,
+ safe_extract_zip,
+ validate_invoke_payload,
+ validate_wasm_file,
+ validate_zip_bytes,
+)
+
SUPPORTED_API_VERSION = 1
PLUGIN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
@@ -38,6 +49,8 @@ class PluginRecord:
install_path: str
auto_disabled_reason: str | None = None
announce_handlers: list[Any] = field(default_factory=list)
+ error_count: int = 0
+ last_error_at: float = 0.0
class PluginManager:
@@ -218,30 +231,24 @@ class PluginManager:
def install_from_zip_bytes(self, payload: bytes) -> dict[str, Any]:
import tempfile
+ validate_zip_bytes(payload)
with tempfile.TemporaryDirectory() as tmp:
zip_path = os.path.join(tmp, "plugin.zip")
with open(zip_path, "wb") as handle:
handle.write(payload)
extract_dir = os.path.join(tmp, "extract")
os.makedirs(extract_dir, exist_ok=True)
- with zipfile.ZipFile(zip_path) as archive:
- archive.extractall(extract_dir)
- plugin_root = extract_dir
- if not os.path.isfile(os.path.join(plugin_root, "plugin.json")):
- children = [
- name
- for name in os.listdir(extract_dir)
- if os.path.isdir(os.path.join(extract_dir, name))
- ]
- if len(children) == 1:
- plugin_root = os.path.join(extract_dir, children[0])
+ plugin_root = safe_extract_zip(zip_path, extract_dir)
return self.install_from_directory(plugin_root)
def enable(self, plugin_id: str) -> dict[str, Any]:
with self._lock:
record = self._require_plugin(plugin_id)
+ self._validate_plugin_runtime(record)
record.enabled = True
record.auto_disabled_reason = None
+ record.error_count = 0
+ record.last_error_at = 0.0
self._write_plugin_state(plugin_id, True, None)
self._register_plugin_hooks(record)
return self._public_plugin_view(record)
@@ -279,14 +286,49 @@ class PluginManager:
def asset_path(self, plugin_id: str, asset_name: str) -> str:
record = self._require_plugin(plugin_id)
- normalized = os.path.normpath(asset_name).replace("\\", "/")
- if normalized.startswith("..") or normalized.startswith("/"):
- raise ValueError("invalid asset path")
+ normalized = normalize_asset_path(asset_name)
path = os.path.join(record.install_path, normalized)
if not os.path.isfile(path):
raise FileNotFoundError(asset_name)
return path
+ def locale_path(self, plugin_id: str, locale: str) -> str | None:
+ record = self._require_plugin(plugin_id)
+ i18n = record.manifest.get("i18n") or {}
+ directory = i18n.get("directory") or "locales"
+ default_locale = i18n.get("defaultLocale") or "en"
+ candidates = []
+ for code in (locale, default_locale, "en"):
+ if code and code not in candidates:
+ candidates.append(code)
+ for code in candidates:
+ relative = os.path.join(directory, f"{code}.json").replace("\\", "/")
+ path = os.path.join(record.install_path, relative)
+ if os.path.isfile(path):
+ return path
+ return None
+
+ def load_locale_messages(self, plugin_id: str, locale: str) -> dict[str, Any]:
+ path = self.locale_path(plugin_id, locale)
+ if not path:
+ return {}
+ with open(path, encoding="utf-8") as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ raise ValueError("plugin locale file must be an object")
+ return data
+
+ def report_failure(
+ self, plugin_id: str, reason: str, source: str = "frontend"
+ ) -> dict[str, Any] | None:
+ with self._lock:
+ record = self._plugins.get(plugin_id)
+ if not record:
+ return None
+ return self._record_plugin_failure(
+ record, f"{source}: {reason}", auto_disable=True
+ )
+
def _require_plugin(self, plugin_id: str) -> PluginRecord:
record = self._plugins.get(plugin_id)
if not record:
@@ -337,7 +379,32 @@ class PluginManager:
def _destination_path_read(self, args: dict[str, Any]) -> dict[str, Any]:
if not self.app or not getattr(self.app, "reticulum", None):
- return {"paths": []}
+ return {"paths": [], "total": 0, "responsive": 0, "unresponsive": 0}
+ search = args.get("search")
+ limit = int(args.get("limit") or 200)
+ handler = getattr(self.app, "rnpath_handler", None)
+ if handler:
+ result = handler.get_path_table(
+ search=str(search).strip() if search else None,
+ limit=limit,
+ )
+ paths = [
+ {
+ "destination_hash": entry["hash"],
+ "hops": entry["hops"],
+ "via": entry.get("via"),
+ "interface": entry.get("interface"),
+ "state": entry.get("state"),
+ "timestamp": entry.get("timestamp"),
+ }
+ for entry in result.get("table", [])
+ ]
+ return {
+ "paths": paths,
+ "total": result.get("total", len(paths)),
+ "responsive": result.get("responsive", 0),
+ "unresponsive": result.get("unresponsive", 0),
+ }
destination_hash = args.get("destination_hash")
paths: list[dict[str, Any]] = []
reticulum = self.app.reticulum
@@ -367,7 +434,12 @@ class PluginManager:
paths.append({"destination_hash": item, "hops": hops})
except Exception:
paths.append({"destination_hash": item, "hops": None})
- return {"paths": paths}
+ return {
+ "paths": paths,
+ "total": len(paths),
+ "responsive": 0,
+ "unresponsive": 0,
+ }
def invoke(
self, plugin_id: str, method: str, args: dict[str, Any] | None = None
@@ -376,32 +448,52 @@ class PluginManager:
if not record.enabled:
raise PermissionError("plugin is disabled")
args = args or {}
- if method == "callManager":
- return self.call_manager(
- plugin_id, args.get("capability"), args.get("args") or {}
- )
- if method == "getState":
- watched = self.storage_get(plugin_id, "watched_nodes")
- return {"watched_nodes": json.loads(watched) if watched else []}
- if method == "setWatchedNodes":
- nodes = args.get("nodes") or []
- self.storage_set(plugin_id, "watched_nodes", json.dumps(nodes))
- return {"ok": True}
- if method == "readPaths":
- return self.call_manager(plugin_id, "destinationPath.read", args)
- backend = record.manifest.get("backend")
- if not backend:
- raise ValueError(f"unknown method: {method}")
- return self._invoke_wasm(record, method, args or {})
+ try:
+ if method == "callManager":
+ return self.call_manager(
+ plugin_id, args.get("capability"), args.get("args") or {}
+ )
+ if method == "getState":
+ watched = self.storage_get(plugin_id, "watched_nodes")
+ return {"watched_nodes": json.loads(watched) if watched else []}
+ if method == "setWatchedNodes":
+ nodes = args.get("nodes") or []
+ self.storage_set(plugin_id, "watched_nodes", json.dumps(nodes))
+ return {"ok": True}
+ if method == "readPaths":
+ return self.call_manager(plugin_id, "destinationPath.read", args)
+ backend = record.manifest.get("backend")
+ if not backend:
+ raise ValueError(f"unknown method: {method}")
+ return self._invoke_wasm(record, method, args or {})
+ except Exception as exc:
+ self._record_plugin_failure(record, exc, auto_disable=True)
+ raise
+
+ def _resolve_backend_wasm_path(self, record: PluginRecord) -> str:
+ backend = record.manifest["backend"]
+ wasm_path = os.path.join(record.install_path, backend["entry"])
+ if not os.path.isfile(wasm_path):
+ return self._ensure_minimal_wasm(record)
+ try:
+ validate_wasm_file(wasm_path)
+ with open(wasm_path, "rb") as handle:
+ if handle.read(4) != b"\x00asm":
+ raise PluginSecurityError("invalid wasm module")
+ except (PluginSecurityError, OSError, ValueError):
+ parent = os.path.dirname(wasm_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ if os.path.isfile(wasm_path):
+ os.remove(wasm_path)
+ return self._ensure_minimal_wasm(record)
+ return wasm_path
def _invoke_wasm(
self, record: PluginRecord, method: str, args: dict[str, Any]
) -> Any:
wasmtime = self._load_wasmtime()
- backend = record.manifest["backend"]
- wasm_path = os.path.join(record.install_path, backend["entry"])
- if not os.path.isfile(wasm_path):
- wasm_path = self._ensure_minimal_wasm(record)
+ wasm_path = self._resolve_backend_wasm_path(record)
engine = wasmtime.Engine()
module = wasmtime.Module.from_file(engine, wasm_path)
store = wasmtime.Store(engine)
@@ -424,6 +516,7 @@ class PluginManager:
)
instance = linker.instantiate(store, module)
payload = json.dumps({"method": method, "args": args}).encode("utf-8")
+ validate_invoke_payload(payload)
memory = instance.exports(store)["memory"]
alloc = instance.exports(store).get("alloc")
if alloc:
@@ -453,12 +546,12 @@ class PluginManager:
def _ensure_minimal_wasm(self, record: PluginRecord) -> str:
wasmtime = self._load_wasmtime()
- engine = wasmtime.Engine()
- module = wasmtime.Module(engine, MINIMAL_PLUGIN_WAT)
- wasm_path = os.path.join(record.install_path, "backend", "plugin.wasm")
+ wasm_bytes = wasmtime.wat2wasm(MINIMAL_PLUGIN_WAT)
+ backend = record.manifest["backend"]
+ wasm_path = os.path.join(record.install_path, backend["entry"])
os.makedirs(os.path.dirname(wasm_path), exist_ok=True)
with open(wasm_path, "wb") as handle:
- handle.write(module.serialize())
+ handle.write(wasm_bytes)
return wasm_path
def dispatch_hook(self, plugin_id: str, hook: str, payload: dict[str, Any]) -> None:
@@ -468,10 +561,59 @@ class PluginManager:
if not self._hook_allowed(record, hook):
return
try:
- self._invoke_wasm(record, "on_hook", {"hook": hook, "payload": payload})
+ if record.manifest.get("backend"):
+ self._invoke_wasm(record, "on_hook", {"hook": hook, "payload": payload})
self._broadcast_plugin_event(plugin_id, hook, payload)
except Exception as exc:
- self.disable(plugin_id, reason=str(exc))
+ self._record_plugin_failure(record, exc, auto_disable=True)
+
+ def _validate_plugin_runtime(self, record: PluginRecord) -> None:
+ manifest = record.manifest
+ frontend = manifest.get("frontend")
+ if frontend:
+ entry = frontend.get("entry")
+ if not isinstance(entry, str) or not entry.strip():
+ raise ValueError("plugin frontend entry is missing")
+ frontend_path = self.asset_path(record.id, entry)
+ if os.path.getsize(frontend_path) <= 0:
+ raise ValueError("plugin frontend entry is empty")
+ backend = manifest.get("backend")
+ if not backend:
+ return
+ entry = backend.get("entry")
+ if not isinstance(entry, str) or not entry.strip():
+ raise ValueError("plugin backend entry is missing")
+ wasm_path = self._resolve_backend_wasm_path(record)
+ wasmtime = self._load_wasmtime()
+ engine = wasmtime.Engine()
+ try:
+ wasmtime.Module.from_file(engine, wasm_path)
+ except Exception as exc:
+ raise ValueError(f"invalid backend wasm module: {exc}") from exc
+
+ def _record_plugin_failure(
+ self,
+ record: PluginRecord,
+ exc: Exception | str,
+ *,
+ auto_disable: bool,
+ ) -> dict[str, Any] | None:
+ should_disable = False
+ disable_reason = str(exc)
+ with self._lock:
+ now = time.time()
+ if now - record.last_error_at > PLUGIN_ERROR_WINDOW_SECONDS:
+ record.error_count = 0
+ record.error_count += 1
+ record.last_error_at = now
+ if auto_disable and record.error_count >= PLUGIN_ERROR_BUDGET:
+ should_disable = True
+ disable_reason = (
+ f"Auto-disabled after {record.error_count} errors: {disable_reason}"
+ )
+ if should_disable:
+ return self.disable(record.id, reason=disable_reason)
+ return None
def on_announce_received(
self,

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 79d1477a..837e4485 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -237,7 +237,7 @@
/>
</template>
<template #text>
- <span>{{ $t(item.labelKey) }}</span>
+ <span>{{ item.label || $t(item.labelKey) }}</span>
<span
v-if="getNavBadgeCount(item) > 0 && !item.badge?.pill"
class="ml-auto mr-2"

diff --git a/meshchatx/src/frontend/components/plugins/PluginPage.vue b/meshchatx/src/frontend/components/plugins/PluginPage.vue
index fdbcf537..475228b7 100644
--- a/meshchatx/src/frontend/components/plugins/PluginPage.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginPage.vue
@@ -2,13 +2,10 @@
<template>
<div class="h-full overflow-y-auto p-4 sm:p-6">
- <div class="mx-auto max-w-3xl rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 sm:p-6">
- <PluginSlotRenderer
- :plugin-id="pluginId"
- :descriptor="descriptor"
- @action="onAction"
- @input="onInput"
- />
+ <div
+ class="mx-auto max-w-3xl rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 sm:p-6"
+ >
+ <PluginSlotRenderer :plugin-id="pluginId" :descriptor="descriptor" @action="onAction" @input="onInput" />
</div>
</div>
</template>

diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
index 7757d3c4..ad8b4fd3 100644
--- a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
@@ -6,7 +6,9 @@
:class="
node.variant === 'title'
? 'text-lg font-semibold text-gray-900 dark:text-gray-100'
- : 'text-sm text-gray-700 dark:text-gray-300'
+ : node.variant === 'mono'
+ ? 'font-mono text-xs text-gray-800 dark:text-gray-200 break-all'
+ : 'text-sm text-gray-700 dark:text-gray-300'
"
>
{{ node.value }}

diff --git a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
index 4a3884bb..eb8a5643 100644
--- a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
@@ -7,22 +7,90 @@
:description="$t('plugins.settings.description')"
>
<div class="space-y-4">
+ <div
+ class="rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/40 p-6 text-center transition-colors"
+ :class="dragActive ? 'border-blue-500 bg-blue-50/60 dark:bg-blue-950/20' : ''"
+ @dragenter.prevent="dragActive = true"
+ @dragover.prevent="dragActive = true"
+ @dragleave.prevent="dragActive = false"
+ @drop.prevent="onDropArchive"
+ >
+ <p class="text-sm font-medium text-gray-800 dark:text-gray-200">
+ {{ $t("plugins.settings.drag_drop") }}
+ </p>
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("plugins.settings.install_zip") }}
+ </p>
+ <label class="mt-4 inline-flex">
+ <input
+ ref="fileInput"
+ type="file"
+ accept=".zip,application/zip"
+ class="sr-only"
+ :disabled="installing"
+ @change="onInstallFile"
+ />
+ <span
+ class="px-4 py-2 rounded-md bg-blue-600 text-white text-sm cursor-pointer hover:bg-blue-700"
+ :class="installing ? 'opacity-60 pointer-events-none' : ''"
+ >
+ {{ installing ? $t("plugins.settings.installing") : $t("plugins.settings.choose_file") }}
+ </span>
+ </label>
+ </div>
+
+ <div
+ v-if="!plugins.length"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 px-4 py-8 text-center text-sm text-gray-600 dark:text-gray-400"
+ >
+ {{ $t("plugins.settings.empty_state") }}
+ </div>
+
<div
v-for="plugin in plugins"
:key="plugin.id"
class="rounded-lg border border-gray-200 dark:border-zinc-800 p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
- <div>
- <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ plugin.name }}</h3>
+ <div class="min-w-0 space-y-2">
+ <div class="flex flex-wrap items-center gap-2">
+ <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ plugin.name }}</h3>
+ <span
+ class="px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide"
+ :class="
+ plugin.enabled
+ ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
+ : 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'
+ "
+ >
+ {{
+ plugin.enabled
+ ? $t("plugins.settings.badge_enabled")
+ : $t("plugins.settings.badge_disabled")
+ }}
+ </span>
+ <span
+ v-if="plugin.has_frontend"
+ class="px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-200"
+ >
+ {{ $t("plugins.settings.badge_frontend") }}
+ </span>
+ <span
+ v-if="plugin.has_backend"
+ class="px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-200"
+ >
+ {{ $t("plugins.settings.badge_wasm") }}
+ </span>
+ </div>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ plugin.description }}</p>
- <p class="text-xs text-gray-500 dark:text-gray-500 mt-1">{{ plugin.id }} · v{{ plugin.version }}</p>
+ <p class="text-xs text-gray-500 dark:text-gray-500">{{ plugin.id }} · v{{ plugin.version }}</p>
</div>
- <div class="flex gap-2">
+ <div class="flex flex-wrap gap-2">
<button
v-if="!plugin.enabled"
type="button"
class="px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm"
+ :disabled="busyPluginId === plugin.id"
@click="enablePlugin(plugin.id)"
>
{{ $t("plugins.settings.enable") }}
@@ -31,6 +99,7 @@
v-else
type="button"
class="px-3 py-1.5 rounded-md bg-zinc-600 text-white text-sm"
+ :disabled="busyPluginId === plugin.id"
@click="disablePlugin(plugin.id)"
>
{{ $t("plugins.settings.disable") }}
@@ -38,7 +107,8 @@
<button
type="button"
class="px-3 py-1.5 rounded-md border border-red-300 text-red-600 text-sm"
- @click="removePlugin(plugin.id)"
+ :disabled="busyPluginId === plugin.id"
+ @click="confirmRemove(plugin)"
>
{{ $t("plugins.settings.remove") }}
</button>
@@ -54,10 +124,6 @@
{{ $t("plugins.settings.auto_disabled", { reason: plugin.auto_disabled_reason }) }}
</p>
</div>
- <label class="block">
- <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t("plugins.settings.install_zip") }}</span>
- <input type="file" accept=".zip,application/zip" class="mt-1 block w-full text-sm" @change="onInstallFile" />
- </label>
</div>
</SettingsSectionBlock>
</template>
@@ -81,6 +147,9 @@ export default {
data() {
return {
plugins: [],
+ dragActive: false,
+ installing: false,
+ busyPluginId: null,
};
},
mounted() {
@@ -88,6 +157,7 @@ export default {
this.onPluginDisabled = (payload) => {
if (payload?.event === "plugin.disabled") {
ToastUtils.warning(this.$t("plugins.settings.kill_switch", { reason: payload?.payload?.reason || "" }));
+ pluginHost.unloadPlugin(payload?.plugin_id);
void this.refresh();
}
};
@@ -97,6 +167,9 @@ export default {
offWsEvent("plugin.event", this.onPluginDisabled);
},
methods: {
+ currentLocale() {
+ return this.$i18n?.locale?.value || this.$i18n?.locale || "en";
+ },
permissionLines(plugin) {
return manifestPermissionSummary(plugin.manifest || { permissions: plugin.permissions || {} });
},
@@ -105,34 +178,81 @@ export default {
this.plugins = response.data?.plugins || [];
},
async enablePlugin(pluginId) {
- await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/enable`);
- await pluginHost.loadEnabledPlugins(window.api, (key) => this.$t(key));
- await this.refresh();
- ToastUtils.success(this.$t("plugins.settings.enabled"));
+ this.busyPluginId = pluginId;
+ try {
+ await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/enable`);
+ await pluginHost.loadEnabledPlugins(window.api, this.currentLocale());
+ await this.refresh();
+ ToastUtils.success(this.$t("plugins.settings.enabled"));
+ } catch (error) {
+ ToastUtils.error(
+ this.$t("plugins.settings.install_failed", { reason: error?.message || String(error) })
+ );
+ await this.refresh();
+ } finally {
+ this.busyPluginId = null;
+ }
},
async disablePlugin(pluginId) {
- await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/disable`);
- pluginHost.unloadPlugin(pluginId);
- await this.refresh();
- ToastUtils.info(this.$t("plugins.settings.disabled"));
+ this.busyPluginId = pluginId;
+ try {
+ await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/disable`);
+ pluginHost.unloadPlugin(pluginId);
+ await this.refresh();
+ ToastUtils.info(this.$t("plugins.settings.disabled"));
+ } finally {
+ this.busyPluginId = null;
+ }
+ },
+ confirmRemove(plugin) {
+ const prompt = this.$t("plugins.settings.confirm_remove", { name: plugin.name || plugin.id });
+ if (!window.confirm(prompt)) {
+ return;
+ }
+ void this.removePlugin(plugin.id);
},
async removePlugin(pluginId) {
- await window.api.delete(`/api/v1/plugins/${encodeURIComponent(pluginId)}`);
- pluginHost.unloadPlugin(pluginId);
- await this.refresh();
- ToastUtils.info(this.$t("plugins.settings.removed"));
+ this.busyPluginId = pluginId;
+ try {
+ await window.api.delete(`/api/v1/plugins/${encodeURIComponent(pluginId)}`);
+ pluginHost.unloadPlugin(pluginId);
+ await this.refresh();
+ ToastUtils.info(this.$t("plugins.settings.removed"));
+ } finally {
+ this.busyPluginId = null;
+ }
},
- async onInstallFile(event) {
- const file = event.target.files?.[0];
+ async installArchive(file) {
if (!file) {
return;
}
- const formData = new FormData();
- formData.append("archive", file);
- await window.api.post("/api/v1/plugins/install", formData);
- await this.refresh();
- ToastUtils.success(this.$t("plugins.settings.installed"));
- event.target.value = "";
+ this.installing = true;
+ try {
+ const formData = new FormData();
+ formData.append("archive", file);
+ await window.api.post("/api/v1/plugins/install", formData);
+ await this.refresh();
+ ToastUtils.success(this.$t("plugins.settings.installed"));
+ } catch (error) {
+ ToastUtils.error(
+ this.$t("plugins.settings.install_failed", { reason: error?.message || String(error) })
+ );
+ } finally {
+ this.installing = false;
+ this.dragActive = false;
+ if (this.$refs.fileInput) {
+ this.$refs.fileInput.value = "";
+ }
+ }
+ },
+ async onInstallFile(event) {
+ const file = event.target.files?.[0];
+ await this.installArchive(file);
+ },
+ async onDropArchive(event) {
+ this.dragActive = false;
+ const file = event.dataTransfer?.files?.[0];
+ await this.installArchive(file);
},
},
};

diff --git a/meshchatx/src/frontend/js/plugins/PluginHost.js b/meshchatx/src/frontend/js/plugins/PluginHost.js
index 8a2cf91b..8a241d87 100644
--- a/meshchatx/src/frontend/js/plugins/PluginHost.js
+++ b/meshchatx/src/frontend/js/plugins/PluginHost.js
@@ -1,39 +1,45 @@
// SPDX-License-Identifier: 0BSD
import { validatePluginManifest } from "./pluginManifest.js";
-import { buildPluginLabelMap } from "./pluginLabels.js";
+import { loadPluginLabelMap, resolvePluginUiString } from "./pluginLabels.js";
+import { setPluginUiLabels, clearPluginUiLabels } from "./pluginUiRegistry.js";
import { registerNavItem, unregisterNavItem } from "../registries/navRegistry.js";
import { registerTool, unregisterTool } from "../registries/toolsRegistry.js";
import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
/** @typedef {import('./pluginManifest.js').PluginManifest} PluginManifest */
+const FAILURE_REPORT_INTERVAL_MS = 5000;
+/** @type {Map<string, number>} */
+const lastFailureReportAt = new Map();
+
export class PluginHost {
constructor() {
- /** @type {Map<string, { worker: Worker, cleanup: Array<() => void>, manifest: PluginManifest, lastDescriptor: object | null }>} */
+ /** @type {Map<string, { worker: Worker, cleanup: Array<() => void>, manifest: PluginManifest, lastDescriptor: object | null, apiClient: ReturnType<import('../apiClient.js').createApiClient> | null }>} */
this.instances = new Map();
}
/**
- * @param {(key: string) => string} [translate]
+ * @param {ReturnType<import('../apiClient.js').createApiClient>} apiClient
+ * @param {string} [locale]
*/
- async loadEnabledPlugins(apiClient, translate) {
+ async loadEnabledPlugins(apiClient, locale = "en") {
const response = await apiClient.get("/api/v1/plugins");
const plugins = response.data?.plugins || [];
- const labels = typeof translate === "function" ? buildPluginLabelMap(translate) : {};
for (const plugin of plugins) {
if (!plugin.enabled) {
continue;
}
- await this.loadPlugin(plugin, apiClient, labels);
+ await this.loadPlugin(plugin, apiClient, locale);
}
}
/**
* @param {Record<string, unknown>} plugin
* @param {ReturnType<import('../apiClient.js').createApiClient>} apiClient
+ * @param {string} [locale]
*/
- async loadPlugin(plugin, apiClient, labels = {}) {
+ async loadPlugin(plugin, apiClient, locale = "en") {
const pluginId = plugin.id;
if (this.instances.has(pluginId)) {
return;
@@ -42,16 +48,25 @@ export class PluginHost {
if (!manifest.frontend) {
return;
}
+ const labels = await loadPluginLabelMap(apiClient, pluginId, locale, manifest);
+ setPluginUiLabels(pluginId, labels);
const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}`;
const sourceResponse = await apiClient.get(assetUrl, { responseType: "text" });
- const source = typeof sourceResponse.data === "string" ? sourceResponse.data : String(sourceResponse.data ?? "");
+ const source =
+ typeof sourceResponse.data === "string" ? sourceResponse.data : String(sourceResponse.data ?? "");
const worker = new Worker(new URL("./pluginWorker.js", import.meta.url), { type: "module" });
const cleanup = [];
worker.onmessage = (event) => {
- this.handleWorkerMessage(pluginId, event.data);
+ this.handleWorkerMessage(pluginId, event.data, apiClient);
};
- worker.onerror = () => {
+ worker.onerror = (event) => {
+ void this.reportPluginFailure(
+ pluginId,
+ event.message || "Plugin worker crashed",
+ apiClient,
+ "frontend-worker"
+ );
this.unloadPlugin(pluginId);
};
@@ -63,7 +78,8 @@ export class PluginHost {
labels,
});
- cleanup.push(...this.registerContributions(pluginId, manifest));
+ cleanup.push(...this.registerContributions(pluginId, manifest, labels));
+ cleanup.push(() => clearPluginUiLabels(pluginId));
if ((manifest.permissions?.hooks || []).length > 0) {
const eventHandler = (payload) => {
if (payload?.plugin_id !== pluginId) {
@@ -110,7 +126,36 @@ export class PluginHost {
void requestHandler(event.data);
});
- this.instances.set(pluginId, { worker, cleanup, manifest, lastDescriptor: null });
+ this.instances.set(pluginId, {
+ worker,
+ cleanup,
+ manifest,
+ lastDescriptor: null,
+ apiClient,
+ });
+ }
+
+ /**
+ * @param {string} pluginId
+ * @param {string} reason
+ * @param {ReturnType<import('../apiClient.js').createApiClient>} apiClient
+ * @param {string} [source]
+ */
+ async reportPluginFailure(pluginId, reason, apiClient, source = "frontend") {
+ const now = Date.now();
+ const last = lastFailureReportAt.get(pluginId) || 0;
+ if (now - last < FAILURE_REPORT_INTERVAL_MS) {
+ return;
+ }
+ lastFailureReportAt.set(pluginId, now);
+ try {
+ await apiClient.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/report-failure`, {
+ reason,
+ source,
+ });
+ } catch (error) {
+ console.debug("Plugin failure report failed:", error);
+ }
}
getLastDescriptor(pluginId) {
@@ -128,16 +173,26 @@ export class PluginHost {
/**
* @param {string} pluginId
* @param {PluginManifest} manifest
+ * @param {Record<string, string>} labels
*/
- registerContributions(pluginId, manifest) {
+ registerContributions(pluginId, manifest, labels) {
const cleanup = [];
const contributes = manifest.contributes || {};
for (const item of contributes.navItems || []) {
- registerNavItem({ ...item, pluginId });
+ registerNavItem({
+ ...item,
+ pluginId,
+ label: resolvePluginUiString(labels, item.labelKey, manifest),
+ });
cleanup.push(() => unregisterNavItem(item.id));
}
for (const item of contributes.toolsPageEntries || []) {
- registerTool({ ...item, pluginId });
+ registerTool({
+ ...item,
+ pluginId,
+ title: resolvePluginUiString(labels, item.titleKey, manifest),
+ description: resolvePluginUiString(labels, item.descriptionKey, manifest),
+ });
cleanup.push(() => unregisterTool(item.name));
}
return cleanup;
@@ -146,8 +201,9 @@ export class PluginHost {
/**
* @param {string} pluginId
* @param {unknown} message
+ * @param {ReturnType<import('../apiClient.js').createApiClient>} [apiClient]
*/
- handleWorkerMessage(pluginId, message) {
+ handleWorkerMessage(pluginId, message, apiClient) {
if (!message || typeof message !== "object") {
return;
}
@@ -163,11 +219,16 @@ export class PluginHost {
);
}
if (message.type === "error") {
+ const client = apiClient || this.instances.get(pluginId)?.apiClient;
+ if (client) {
+ void this.reportPluginFailure(pluginId, message.message || "Plugin activation failed", client);
+ }
window.dispatchEvent(
new CustomEvent("meshchatx-plugin-error", {
detail: { pluginId, message: message.message },
})
);
+ this.unloadPlugin(pluginId);
}
}
@@ -181,6 +242,7 @@ export class PluginHost {
fn();
}
this.instances.delete(pluginId);
+ lastFailureReportAt.delete(pluginId);
}
postAction(pluginId, actionId) {

diff --git a/meshchatx/src/frontend/js/plugins/pluginLabels.js b/meshchatx/src/frontend/js/plugins/pluginLabels.js
index 5777c09c..1504f0a1 100644
--- a/meshchatx/src/frontend/js/plugins/pluginLabels.js
+++ b/meshchatx/src/frontend/js/plugins/pluginLabels.js
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: 0BSD
-import en from "../../locales/en.json";
-
/**
* Flatten nested locale objects into dotted keys for plugin worker translation.
*
@@ -30,43 +28,57 @@ export function flattenLocaleMessages(messages, prefix = "") {
}
/**
- * @param {Record<string, unknown>} messages
- * @param {string} [prefix]
- * @returns {string[]}
+ * @param {ReturnType<import('../apiClient.js').createApiClient>} apiClient
+ * @param {string} pluginId
+ * @param {string} locale
+ * @param {Record<string, unknown>} [manifest]
+ * @returns {Promise<Record<string, string>>}
*/
-function collectLocaleKeys(messages, prefix = "") {
- /** @type {string[]} */
- const keys = [];
- if (!messages || typeof messages !== "object") {
- return keys;
- }
- for (const [key, value] of Object.entries(messages)) {
- if (key.startsWith("_")) {
- continue;
+export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest = {}) {
+ const i18n = manifest.i18n || {};
+ const directory = i18n.directory || "locales";
+ const defaultLocale = i18n.defaultLocale || "en";
+ const candidates = [];
+ for (const code of [locale, defaultLocale, "en"]) {
+ if (code && !candidates.includes(code)) {
+ candidates.push(code);
}
- const path = prefix ? `${prefix}.${key}` : key;
- if (typeof value === "string") {
- keys.push(path);
- } else if (value && typeof value === "object" && !Array.isArray(value)) {
- keys.push(...collectLocaleKeys(value, path));
+ }
+ for (const code of candidates) {
+ try {
+ const assetPath = `${directory}/${code}.json`;
+ const response = await apiClient.get(
+ `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}`,
+ { responseType: "json" }
+ );
+ if (response.data && typeof response.data === "object") {
+ return flattenLocaleMessages(response.data);
+ }
+ } catch {
+ // try next locale candidate
}
}
- return keys;
+ return {};
}
/**
- * @param {(key: string) => string} translate
- * @returns {Record<string, string>}
+ * @param {Record<string, string>} labels
+ * @param {string} key
+ * @param {Record<string, unknown>} [manifest]
+ * @returns {string}
*/
-export function buildPluginLabelMap(translate) {
- /** @type {Record<string, string>} */
- const labels = {};
- const keys = collectLocaleKeys(en.plugins || {}, "plugins");
- for (const key of keys) {
- const value = translate(key);
- if (typeof value === "string" && value !== key) {
- labels[key] = value;
- }
+export function resolvePluginUiString(labels, key, manifest = {}) {
+ if (labels[key]) {
+ return labels[key];
+ }
+ if (key === "title") {
+ return typeof manifest.name === "string" ? manifest.name : key;
+ }
+ if (key === "description") {
+ return typeof manifest.description === "string" ? manifest.description : key;
+ }
+ if (key === "nav") {
+ return typeof manifest.name === "string" ? manifest.name : key;
}
- return labels;
+ return key;
}

diff --git a/meshchatx/src/frontend/js/plugins/pluginUiRegistry.js b/meshchatx/src/frontend/js/plugins/pluginUiRegistry.js
new file mode 100644
index 00000000..41ffbd65
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/pluginUiRegistry.js
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: 0BSD
+
+/** @type {Map<string, Record<string, string>>} */
+const labelsByPlugin = new Map();
+
+/**
+ * @param {string} pluginId
+ * @param {Record<string, string>} labels
+ */
+export function setPluginUiLabels(pluginId, labels) {
+ labelsByPlugin.set(pluginId, labels);
+}
+
+/**
+ * @param {string} pluginId
+ */
+export function clearPluginUiLabels(pluginId) {
+ labelsByPlugin.delete(pluginId);
+}
+
+/**
+ * @param {string} pluginId
+ * @returns {Record<string, string>}
+ */
+export function getPluginUiLabels(pluginId) {
+ return labelsByPlugin.get(pluginId) || {};
+}

diff --git a/meshchatx/src/frontend/js/registries/coreNavEntries.js b/meshchatx/src/frontend/js/registries/coreNavEntries.js
index 8bc57498..207b3598 100644
--- a/meshchatx/src/frontend/js/registries/coreNavEntries.js
+++ b/meshchatx/src/frontend/js/registries/coreNavEntries.js
@@ -8,6 +8,7 @@
* @property {{ name: string }} route
* @property {string} icon
* @property {string} labelKey
+ * @property {string} [label]
* @property {{ source: NavBadgeSource, pill?: boolean, cap?: number } | null} [badge]
* @property {'rrcEnabled' | null} [visibleWhen]
* @property {string | null} [pluginId]

diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index af0ed9d2..aa110619 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -2,16 +2,7 @@
/** @type {Record<string, string[]>} */
export const CORE_SETTINGS_SECTION_KEYWORDS = {
- telephony: [
- "Telephony",
- "Telephone",
- "LXST",
- "Enable Telephone",
- "voice",
- "calling",
- "call",
- "mesh network",
- ],
+ telephony: ["Telephony", "Telephone", "LXST", "Enable Telephone", "voice", "calling", "call", "mesh network"],
strangerProtection: [
"Security",
"app.stranger_protection",
@@ -221,12 +212,7 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.enable_transport_mode",
"app.transport_toggle_description",
],
- interfaces: [
- "Adapters",
- "app.interfaces",
- "app.show_community_interfaces",
- "app.community_interfaces_description",
- ],
+ interfaces: ["Adapters", "app.interfaces", "app.show_community_interfaces", "app.community_interfaces_description"],
blocked: ["Privacy", "Banished", "Manage Banished users and nodes"],
auth: ["Security", "Authentication", "password", "Protect your instance with a password"],
webExposure: [

diff --git a/meshchatx/src/frontend/js/registries/toolsRegistry.js b/meshchatx/src/frontend/js/registries/toolsRegistry.js
index a0c811e6..9bbc1dd3 100644
--- a/meshchatx/src/frontend/js/registries/toolsRegistry.js
+++ b/meshchatx/src/frontend/js/registries/toolsRegistry.js
@@ -25,5 +25,9 @@ export function unregisterTool(name) {
* @returns {ToolEntry[]}
*/
export function listTools() {
- return toolsRegistry.list().map(({ id: _id, ...entry }) => entry);
+ return toolsRegistry.list().map((entry) => {
+ const tool = { ...entry };
+ delete tool.id;
+ return tool;
+ });
}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 67f755c0..9d3354de 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -659,16 +659,17 @@
"removed": "Plugin entfernt",
"installed": "Plugin installiert",
"auto_disabled": "Plugin automatisch deaktiviert: {reason}",
- "kill_switch": "Ein Plugin wurde deaktiviert: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Transportknoten",
- "title": "Transportknoten-Monitor",
- "description": "Beobachtete Transportknoten-Ziele und Pfad-Hop-Anzahl verfolgen.",
- "watch_hash": "Ziel-Hash",
- "watch_hash_placeholder": "Ziel-Hash zum Beobachten eingeben",
- "add_watch": "Beobachtung hinzufügen",
- "no_path": "Kein Pfad"
+ "kill_switch": "Ein Plugin wurde deaktiviert: {reason}",
+ "empty_state": "Noch keine Plugins installiert. Laden Sie ein Plugin-ZIP hoch, um zu starten.",
+ "drag_drop": "Plugin-ZIP hierher ziehen",
+ "choose_file": "ZIP-Datei wählen",
+ "confirm_remove": "Plugin \"{name}\" entfernen? Dateien und gespeicherte Daten werden gelöscht.",
+ "badge_enabled": "Aktiv",
+ "badge_disabled": "Inaktiv",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Plugin-Installation fehlgeschlagen: {reason}",
+ "installing": "Plugin wird installiert..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 3e9c51e6..bfb6823b 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -659,16 +659,17 @@
"removed": "Plugin removed",
"installed": "Plugin installed",
"auto_disabled": "Plugin auto-disabled: {reason}",
- "kill_switch": "A plugin was disabled: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Transport Nodes",
- "title": "Transport Node Monitor",
- "description": "Watch transport node destinations and path hop counts.",
- "watch_hash": "Destination hash",
- "watch_hash_placeholder": "Enter a destination hash to watch",
- "add_watch": "Add watch",
- "no_path": "No path"
+ "kill_switch": "A plugin was disabled: {reason}",
+ "empty_state": "No plugins installed yet. Upload a plugin ZIP archive to get started.",
+ "drag_drop": "Drag and drop a plugin ZIP here",
+ "choose_file": "Choose ZIP file",
+ "confirm_remove": "Remove plugin \"{name}\"? This deletes its files and stored data.",
+ "badge_enabled": "Enabled",
+ "badge_disabled": "Disabled",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Plugin install failed: {reason}",
+ "installing": "Installing plugin..."
}
},
"maintenance": {
@@ -2390,7 +2391,7 @@
"host_rooms": "rooms",
"host_private": "private",
"host_delete_room": "Delete room",
- "host_no_rooms": "No rooms yet. Create a room below.",
+ "host_no_rooms": "No rooms yet.",
"host_room_name": "room name",
"host_room_topic": "topic (optional)",
"host_add_room": "Add Room",
@@ -2409,7 +2410,6 @@
"host_members_search": "Search members...",
"host_rooms_search": "Search rooms...",
"host_rooms_search_empty": "No rooms match your search.",
- "host_no_rooms": "No rooms yet.",
"host_moderation_uptime": "{time} uptime",
"host_moderation_members": "{count} members",
"host_members_select": "Select a member to view messages and moderate",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 6e86575b..af9ba899 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -659,16 +659,17 @@
"removed": "Plugin eliminado",
"installed": "Plugin instalado",
"auto_disabled": "Plugin deshabilitado automáticamente: {reason}",
- "kill_switch": "Se deshabilitó un plugin: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Nodos de transporte",
- "title": "Monitor de nodos de transporte",
- "description": "Supervisar destinos de nodos de transporte y saltos de ruta.",
- "watch_hash": "Hash de destino",
- "watch_hash_placeholder": "Introduce un hash de destino para vigilar",
- "add_watch": "Añadir vigilancia",
- "no_path": "Sin ruta"
+ "kill_switch": "Se deshabilitó un plugin: {reason}",
+ "empty_state": "Aún no hay plugins instalados. Suba un ZIP de plugin para empezar.",
+ "drag_drop": "Arrastre un ZIP de plugin aquí",
+ "choose_file": "Elegir archivo ZIP",
+ "confirm_remove": "¿Eliminar el plugin \"{name}\"? Se borrarán sus archivos y datos.",
+ "badge_enabled": "Activo",
+ "badge_disabled": "Inactivo",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Error al instalar el plugin: {reason}",
+ "installing": "Instalando plugin..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 032b1e44..d103183d 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -659,16 +659,17 @@
"removed": "Liitännäinen poistettu",
"installed": "Liitännäinen asennettu",
"auto_disabled": "Liitännäinen poistettiin käytöstä automaattisesti: {reason}",
- "kill_switch": "Liitännäinen poistettiin käytöstä: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Kuljetussolmut",
- "title": "Kuljetussolmujen valvonta",
- "description": "Seuraa kuljetussolmujen kohteita ja reitin hyppyjen määrää.",
- "watch_hash": "Kohdehash",
- "watch_hash_placeholder": "Syötä seurattava kohdehash",
- "add_watch": "Lisää seuranta",
- "no_path": "Ei reittiä"
+ "kill_switch": "Liitännäinen poistettiin käytöstä: {reason}",
+ "empty_state": "Ei asennettuja lisäosia. Lataa lisäosan ZIP aloittaaksesi.",
+ "drag_drop": "Pudota lisäosan ZIP tähän",
+ "choose_file": "Valitse ZIP-tiedosto",
+ "confirm_remove": "Poistetaanko lisäosa \"{name}\"? Tiedostot ja tallennetut tiedot poistetaan.",
+ "badge_enabled": "Käytössä",
+ "badge_disabled": "Pois",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Lisäosan asennus epäonnistui: {reason}",
+ "installing": "Asennetaan lisäosaa..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index b224d39b..582596f2 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -659,16 +659,17 @@
"removed": "Plugin supprimé",
"installed": "Plugin installé",
"auto_disabled": "Plugin désactivé automatiquement : {reason}",
- "kill_switch": "Un plugin a été désactivé : {reason}"
- },
- "transport_node_monitor": {
- "nav": "Nœuds de transport",
- "title": "Moniteur de nœuds de transport",
- "description": "Surveiller les destinations et le nombre de sauts de chemin des nœuds de transport.",
- "watch_hash": "Hash de destination",
- "watch_hash_placeholder": "Saisir un hash de destination à surveiller",
- "add_watch": "Ajouter une surveillance",
- "no_path": "Aucun chemin"
+ "kill_switch": "Un plugin a été désactivé : {reason}",
+ "empty_state": "Aucun plugin installé. Importez une archive ZIP pour commencer.",
+ "drag_drop": "Déposez une archive ZIP ici",
+ "choose_file": "Choisir un fichier ZIP",
+ "confirm_remove": "Supprimer le plugin « {name} » ? Ses fichiers et données seront effacés.",
+ "badge_enabled": "Activé",
+ "badge_disabled": "Désactivé",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Échec de l'installation du plugin : {reason}",
+ "installing": "Installation du plugin..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 41862496..ac29c0e1 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -659,16 +659,17 @@
"removed": "Plugin rimosso",
"installed": "Plugin installato",
"auto_disabled": "Plugin disabilitato automaticamente: {reason}",
- "kill_switch": "Un plugin è stato disabilitato: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Nodi di trasporto",
- "title": "Monitor nodi di trasporto",
- "description": "Monitora le destinazioni dei nodi di trasporto e i salti di percorso.",
- "watch_hash": "Hash destinazione",
- "watch_hash_placeholder": "Inserisci un hash destinazione da monitorare",
- "add_watch": "Aggiungi monitoraggio",
- "no_path": "Nessun percorso"
+ "kill_switch": "Un plugin è stato disabilitato: {reason}",
+ "empty_state": "Nessun plugin installato. Carica un archivio ZIP per iniziare.",
+ "drag_drop": "Trascina qui un ZIP del plugin",
+ "choose_file": "Scegli file ZIP",
+ "confirm_remove": "Rimuovere il plugin \"{name}\"? Verranno eliminati file e dati salvati.",
+ "badge_enabled": "Attivo",
+ "badge_disabled": "Disattivo",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Installazione plugin non riuscita: {reason}",
+ "installing": "Installazione plugin..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 057cba62..bd1c1a99 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -659,16 +659,17 @@
"removed": "Plugin verwijderd",
"installed": "Plugin geïnstalleerd",
"auto_disabled": "Plugin automatisch uitgeschakeld: {reason}",
- "kill_switch": "Een plugin is uitgeschakeld: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Transportnodes",
- "title": "Transportnode-monitor",
- "description": "Bewaak transportnode-bestemmingen en aantal routehops.",
- "watch_hash": "Bestemmingshash",
- "watch_hash_placeholder": "Voer een bestemmingshash in om te volgen",
- "add_watch": "Volgen toevoegen",
- "no_path": "Geen pad"
+ "kill_switch": "Een plugin is uitgeschakeld: {reason}",
+ "empty_state": "Nog geen plugins geïnstalleerd. Upload een plugin-ZIP om te beginnen.",
+ "drag_drop": "Sleep een plugin-ZIP hierheen",
+ "choose_file": "ZIP-bestand kiezen",
+ "confirm_remove": "Plugin \"{name}\" verwijderen? Bestanden en opgeslagen gegevens worden gewist.",
+ "badge_enabled": "Ingeschakeld",
+ "badge_disabled": "Uitgeschakeld",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Plugin-installatie mislukt: {reason}",
+ "installing": "Plugin installeren..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index abfe8e67..20d27cb1 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -659,16 +659,17 @@
"removed": "Плагин удалён",
"installed": "Плагин установлен",
"auto_disabled": "Плагин автоматически отключён: {reason}",
- "kill_switch": "Плагин был отключён: {reason}"
- },
- "transport_node_monitor": {
- "nav": "Транспортные узлы",
- "title": "Монитор транспортных узлов",
- "description": "Отслеживание назначений транспортных узлов и числа хопов маршрута.",
- "watch_hash": "Хеш назначения",
- "watch_hash_placeholder": "Введите хеш назначения для отслеживания",
- "add_watch": "Добавить отслеживание",
- "no_path": "Нет маршрута"
+ "kill_switch": "Плагин был отключён: {reason}",
+ "empty_state": "Плагины не установлены. Загрузите ZIP-архив плагина, чтобы начать.",
+ "drag_drop": "Перетащите ZIP плагина сюда",
+ "choose_file": "Выбрать ZIP-файл",
+ "confirm_remove": "Удалить плагин «{name}»? Будут удалены его файлы и сохранённые данные.",
+ "badge_enabled": "Включён",
+ "badge_disabled": "Отключён",
+ "badge_frontend": "UI",
+ "badge_wasm": "WASM",
+ "install_failed": "Не удалось установить плагин: {reason}",
+ "installing": "Установка плагина..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 78b19a44..d7e3114a 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -659,16 +659,17 @@
"removed": "插件已移除",
"installed": "插件已安装",
"auto_disabled": "插件已自动禁用:{reason}",
- "kill_switch": "插件已被禁用:{reason}"
- },
- "transport_node_monitor": {
- "nav": "传输节点",
- "title": "传输节点监视器",
- "description": "监视传输节点目标地址和路径跳数。",
- "watch_hash": "目标哈希",
- "watch_hash_placeholder": "输入要监视的目标哈希",
- "add_watch": "添加监视",
- "no_path": "无路径"
+ "kill_switch": "插件已被禁用:{reason}",
+ "empty_state": "尚未安装插件。上传插件 ZIP 压缩包即可开始。",
+ "drag_drop": "将插件 ZIP 拖放到此处",
+ "choose_file": "选择 ZIP 文件",
+ "confirm_remove": "移除插件“{name}”?将删除其文件和存储数据。",
+ "badge_enabled": "已启用",
+ "badge_disabled": "已禁用",
+ "badge_frontend": "界面",
+ "badge_wasm": "WASM",
+ "install_failed": "插件安装失败:{reason}",
+ "installing": "正在安装插件..."
}
},
"maintenance": {

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 9678e8fe..fedff423 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -304,6 +304,12 @@ const router = createRouter({
component: () => import("./components/plugins/PluginPage.vue"),
props: { pluginId: "com.meshchatx.transport-node-monitor" },
},
+ {
+ name: "plugin-mesh-observatory",
+ path: "/plugins/com.meshchatx.mesh-observatory",
+ component: () => import("./components/plugins/PluginPage.vue"),
+ props: { pluginId: "com.meshchatx.mesh-observatory" },
+ },
{
name: "changelog",
path: "/changelog",
@@ -412,7 +418,7 @@ function bootstrap() {
}
void startCodec2ScriptsBackgroundLoad();
if (GlobalState.authenticated || !GlobalState.authEnabled) {
- void pluginHost.loadEnabledPlugins(window.api, (key) => i18n.global.t(key)).catch((error) => {
+ void pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value).catch((error) => {
console.debug("Plugin host bootstrap failed:", error);
});
}

diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index a9243348..31803155 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1,1368 +1,1368 @@
{
- "routes": [
- {
- "method": "GET",
- "path": "/"
- },
- {
- "method": "GET",
- "path": "/api/v1/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/announces"
- },
- {
- "method": "POST",
- "path": "/api/v1/announces/query"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/changelog"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/changelog/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/info"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/integrity/acknowledge"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/shutdown"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/tutorial/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/csrf"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/login"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/logout"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/setup"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "POST",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/blocked-destinations/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/announce"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/delete"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/start"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/subprocess-log"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/bots/update"
- },
- {
- "method": "GET",
- "path": "/api/v1/community-interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/community-interfaces/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/comports"
- },
- {
- "method": "GET",
- "path": "/api/v1/config"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/backup"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backup/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/backups/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups/{filename}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/health"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/restore"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/snapshots/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots/{filename}/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/vacuum"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/access-attempts"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/logs"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/drop-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/path"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/request-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/signal-metrics"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/gc"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/gc/collect"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/heap"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/referrers"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export/reticulum"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/search"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/switch"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/docs/version/{version}"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/favourites/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/{destination_hash}/rename"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/{gif_id}/image"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/{gif_id}/use"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/create"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities/export-all"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/switch"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/identities/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/base32"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/identity/restore"
- },
- {
- "method": "GET",
- "path": "/api/v1/interface-stats"
- },
- {
- "method": "GET",
- "path": "/api/v1/licenses"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/reactions"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/send"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/{hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/spam"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/{message_hash}/uri"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversation-pins"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversation-pins/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversations"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/move-to-folder"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/message-blocklist/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/restart"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/stop-sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-nodes"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/announces"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/archives"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/docs/reticulum"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/favourites"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/gifs"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/lxmf-icons"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/maintenance/messages/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import-file"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/path-table"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/export"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/mbtiles"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/mbtiles/active"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/mbtiles/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/tiles/{z}/{x}/{y}"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/list"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "GET",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "POST",
- "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
- },
- {
- "method": "GET",
- "path": "/api/v1/notifications"
- },
- {
- "method": "POST",
- "path": "/api/v1/notifications/mark-as-viewed"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "PUT",
- "path": "/api/v1/page-nodes/{node_id}/rename"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/path-table"
- },
- {
- "method": "POST",
- "path": "/api/v1/path-table"
- },
- {
- "method": "GET",
- "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/install"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/plugins/{plugin_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/invoke"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/list"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/refresh-bundled"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/repository-server/upload/{name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/blackhole"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "PUT",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/config/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/disable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovered-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/enable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import-preview"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/reload"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/fetch"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/listen"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/send"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/transfer/{transfer_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-queues"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-via"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/rates"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/request"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/table"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/trace/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnprobe"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnsh/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnstatus"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/command"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/activity"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/members"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/moderate"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/server/security"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/server/security"
- },
- {
- "method": "POST",
- "path": "/api/v1/setup/storage-migration"
- },
- {
- "method": "GET",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "POST",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/spam-keywords/{keyword_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/reorder"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/{sticker_id}/image"
- },
- {
- "method": "GET",
- "path": "/api/v1/system/network-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/history/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/latest/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/tracking"
- },
- {
- "method": "POST",
- "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/trusted-peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/answer"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/audio-profiles"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/call/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/check/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/hangup"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-transmit"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/recordings/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/ringtones/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/send-to-voicemail"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-transmit"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/generate-greeting"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemail/greeting"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/greeting/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/stop"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/upload"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemails/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails/{id}/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemails/{id}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/micron-parser-go-release"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/download_firmware"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/latest_release"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/install-languages"
- },
- {
- "method": "GET",
- "path": "/api/v1/translator/languages"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/translate"
- },
- {
- "method": "GET",
- "path": "/call.html"
- },
- {
- "method": "GET",
- "path": "/manifest.json"
- },
- {
- "method": "GET",
- "path": "/service-worker.js"
- },
- {
- "method": "GET",
- "path": "/ws"
- },
- {
- "method": "GET",
- "path": "/ws/telephone/audio"
- }
- ]
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/announces/query"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/changelog"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/changelog/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/info"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/integrity/acknowledge"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/shutdown"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/tutorial/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/csrf"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/login"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/logout"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/setup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/blocked-destinations/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/announce"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/delete"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/start"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/subprocess-log"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/bots/update"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/community-interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/community-interfaces/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/comports"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/backup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backup/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/backups/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups/{filename}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/health"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/restore"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/snapshots/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots/{filename}/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/vacuum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/access-attempts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/logs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/drop-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/path"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/request-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/signal-metrics"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/gc"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/gc/collect"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/heap"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/referrers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export/reticulum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/search"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/switch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/docs/version/{version}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/favourites/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/{destination_hash}/rename"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/{gif_id}/image"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/{gif_id}/use"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/create"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities/export-all"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/switch"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/identities/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/base32"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identity/restore"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/interface-stats"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/licenses"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/reactions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/send"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/{hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/spam"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/{message_hash}/uri"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversation-pins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversation-pins/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/move-to-folder"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/message-blocklist/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/restart"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/stop-sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-nodes"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/announces"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/archives"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/docs/reticulum"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/favourites"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/gifs"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/lxmf-icons"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import-file"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/path-table"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/export"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/mbtiles"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/mbtiles/active"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/mbtiles/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/tiles/{z}/{x}/{y}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/list"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notifications"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notifications/mark-as-viewed"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/page-nodes/{node_id}/rename"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/install"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/{plugin_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/invoke"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/list"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/refresh-bundled"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/repository-server/upload/{name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/blackhole"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/config/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/disable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovered-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/enable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import-preview"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/reload"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/fetch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/listen"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/send"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/transfer/{transfer_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-queues"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-via"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/rates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/request"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/trace/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnprobe"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnsh/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnstatus"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/command"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/activity"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/members"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/moderate"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/setup/storage-migration"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/spam-keywords/{keyword_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/reorder"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/{sticker_id}/image"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/system/network-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/history/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/latest/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/tracking"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/trusted-peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/answer"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/audio-profiles"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/call/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/check/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/hangup"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-transmit"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/recordings/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/ringtones/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/{id}/audio"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/send-to-voicemail"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-transmit"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/generate-greeting"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemail/greeting"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/greeting/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/stop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/upload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemails/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails/{id}/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemails/{id}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/micron-parser-go-release"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/download_firmware"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/latest_release"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/install-languages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/translator/languages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/translate"
+ },
+ {
+ "method": "GET",
+ "path": "/call.html"
+ },
+ {
+ "method": "GET",
+ "path": "/manifest.json"
+ },
+ {
+ "method": "GET",
+ "path": "/service-worker.js"
+ },
+ {
+ "method": "GET",
+ "path": "/ws"
+ },
+ {
+ "method": "GET",
+ "path": "/ws/telephone/audio"
+ }
+ ]
}

diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index 8091e3b7..a4b36ed7 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -19,6 +19,7 @@ class TestPluginManagerInstall:
plugins = manager.list_plugins()
ids = [plugin["id"] for plugin in plugins]
assert "com.meshchatx.transport-node-monitor" in ids
+ assert "com.meshchatx.mesh-observatory" in ids
def test_enable_disable_plugin(self, tmp_path):
manager = _make_manager(tmp_path)
@@ -46,6 +47,38 @@ class TestPluginManagerInstall:
with pytest.raises(PermissionError):
manager.call_manager(plugin_id, "unknown.capability", {})
+ def test_destination_path_read_uses_rnpath_handler(self, tmp_path):
+ class FakeHandler:
+ def get_path_table(self, search=None, limit=0):
+ return {
+ "table": [
+ {
+ "hash": "abc123",
+ "hops": 2,
+ "via": "def456",
+ "interface": "RNode LoRa",
+ "state": 1,
+ "timestamp": 1.0,
+ }
+ ],
+ "total": 1,
+ "responsive": 1,
+ "unresponsive": 0,
+ }
+
+ class FakeApp:
+ reticulum = object()
+ rnpath_handler = FakeHandler()
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ manager.enable(plugin_id)
+ result = manager.call_manager(plugin_id, "destinationPath.read", {"limit": 10})
+ assert result["total"] == 1
+ assert result["paths"][0]["destination_hash"] == "abc123"
+ assert result["paths"][0]["interface"] == "RNode LoRa"
+
def test_manifest_validation_rejects_invalid_id(self, tmp_path):
manager = _make_manager(tmp_path)
plugin_dir = os.path.join(tmp_path, "bad-plugin")

diff --git a/tests/backend/test_plugin_security.py b/tests/backend/test_plugin_security.py
new file mode 100644
index 00000000..5b11f0ee
--- /dev/null
+++ b/tests/backend/test_plugin_security.py
@@ -0,0 +1,135 @@
+# SPDX-License-Identifier: 0BSD
+
+import io
+import json
+import os
+import zipfile
+
+import pytest
+
+from meshchatx.src.backend.plugin_guard import (
+ PluginSecurityError,
+ normalize_asset_path,
+ safe_extract_zip,
+ validate_zip_bytes,
+)
+
+
+def _make_manager(tmp_path, app=None):
+ from meshchatx.src.backend.plugin_manager import PluginManager
+
+ return PluginManager(str(tmp_path), app=app)
+
+
+def _write_plugin_dir(root, plugin_id="com.example.secure-plugin"):
+ os.makedirs(root, exist_ok=True)
+ manifest = {
+ "id": plugin_id,
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Secure Plugin",
+ "description": "Security test plugin",
+ "frontend": {"entry": "frontend/main.js", "type": "js"},
+ "i18n": {"directory": "locales", "defaultLocale": "en"},
+ }
+ with open(os.path.join(root, "plugin.json"), "w", encoding="utf-8") as handle:
+ json.dump(manifest, handle)
+ os.makedirs(os.path.join(root, "frontend"), exist_ok=True)
+ with open(os.path.join(root, "frontend", "main.js"), "w", encoding="utf-8") as handle:
+ handle.write("export async function activate(api) { api.setUi({ type: 'text', value: 'ok' }); }")
+ os.makedirs(os.path.join(root, "locales"), exist_ok=True)
+ with open(os.path.join(root, "locales", "en.json"), "w", encoding="utf-8") as handle:
+ json.dump({"title": "Secure Plugin"}, handle)
+
+
+def _zip_directory(source_dir):
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as archive:
+ for base, _, files in os.walk(source_dir):
+ for name in files:
+ path = os.path.join(base, name)
+ archive.write(path, os.path.relpath(path, source_dir))
+ return buffer.getvalue()
+
+
+class TestPluginGuard:
+ def test_normalize_asset_path_rejects_traversal(self):
+ with pytest.raises(PluginSecurityError):
+ normalize_asset_path("../plugin.json")
+ with pytest.raises(PluginSecurityError):
+ normalize_asset_path("/etc/passwd")
+
+ def test_validate_zip_bytes_rejects_empty_and_random_payload(self):
+ with pytest.raises(PluginSecurityError):
+ validate_zip_bytes(b"")
+ with pytest.raises(PluginSecurityError):
+ validate_zip_bytes(os.urandom(64))
+
+ def test_safe_extract_zip_rejects_zip_slip(self, tmp_path):
+ zip_path = tmp_path / "evil.zip"
+ extract_dir = tmp_path / "extract"
+ extract_dir.mkdir()
+ with zipfile.ZipFile(zip_path, "w") as archive:
+ archive.writestr("../escape.txt", "bad")
+ with pytest.raises(PluginSecurityError):
+ safe_extract_zip(str(zip_path), str(extract_dir))
+
+ def test_install_zip_with_traversal_is_rejected(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ zip_path = tmp_path / "evil.zip"
+ with zipfile.ZipFile(zip_path, "w") as archive:
+ archive.writestr("../escape.txt", "bad")
+ with pytest.raises(PluginSecurityError):
+ manager.install_from_zip_bytes(zip_path.read_bytes())
+
+ def test_install_valid_zip_roundtrip(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ source = tmp_path / "source"
+ _write_plugin_dir(str(source))
+ plugin = manager.install_from_zip_bytes(_zip_directory(str(source)))
+ assert plugin["id"] == "com.example.secure-plugin"
+ assert manager.locale_path(plugin["id"], "en").endswith("locales/en.json")
+
+ def test_asset_path_blocks_traversal(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ source = tmp_path / "source"
+ _write_plugin_dir(str(source))
+ manager.install_from_directory(str(source))
+ with pytest.raises(PluginSecurityError):
+ manager.asset_path("com.example.secure-plugin", "../plugin.json")
+
+ def test_report_failure_auto_disables_after_budget(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ source = tmp_path / "source"
+ _write_plugin_dir(str(source))
+ manager.install_from_directory(str(source))
+ plugin_id = "com.example.secure-plugin"
+ manager.enable(plugin_id)
+ for _ in range(5):
+ manager.report_failure(plugin_id, "worker crash", "frontend")
+ plugin = manager.get_plugin(plugin_id)
+ assert plugin is not None
+ assert plugin["enabled"] is False
+ assert plugin["auto_disabled_reason"]
+
+ def test_enable_rejects_missing_frontend_entry(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ source = tmp_path / "broken"
+ os.makedirs(source, exist_ok=True)
+ manifest = {
+ "id": "com.example.broken",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "frontend": {"entry": "frontend/missing.js", "type": "js"},
+ }
+ with open(os.path.join(source, "plugin.json"), "w", encoding="utf-8") as handle:
+ json.dump(manifest, handle)
+ manager.install_from_directory(str(source))
+ with pytest.raises(FileNotFoundError):
+ manager.enable("com.example.broken")
+
+ @pytest.mark.parametrize("payload", [os.urandom(32), b"not-a-zip", b"\x00\x01\x02"])
+ def test_fuzz_random_install_payloads_are_rejected(self, tmp_path, payload):
+ manager = _make_manager(tmp_path)
+ with pytest.raises(Exception):
+ manager.install_from_zip_bytes(payload)

diff --git a/tests/frontend/pluginLabels.test.js b/tests/frontend/pluginLabels.test.js
index 07899aad..c2409a84 100644
--- a/tests/frontend/pluginLabels.test.js
+++ b/tests/frontend/pluginLabels.test.js
@@ -1,16 +1,41 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it } from "vitest";
-import { buildPluginLabelMap } from "../../meshchatx/src/frontend/js/plugins/pluginLabels.js";
+import {
+ flattenLocaleMessages,
+ loadPluginLabelMap,
+ resolvePluginUiString,
+} from "../../meshchatx/src/frontend/js/plugins/pluginLabels.js";
describe("pluginLabels", () => {
- it("builds flat plugin label map from translate function", () => {
- const labels = buildPluginLabelMap((key) => {
- if (key === "plugins.transport_node_monitor.title") {
- return "Transport Node Monitor";
- }
- return key;
+ it("flattens nested plugin locale messages", () => {
+ const labels = flattenLocaleMessages({
+ title: "Mesh Observatory",
+ nested: { value: "Hello" },
});
- expect(labels["plugins.transport_node_monitor.title"]).toBe("Transport Node Monitor");
+ expect(labels.title).toBe("Mesh Observatory");
+ expect(labels["nested.value"]).toBe("Hello");
+ });
+
+ it("resolves plugin UI strings with manifest fallback", () => {
+ expect(resolvePluginUiString({}, "title", { name: "Fallback Name" })).toBe("Fallback Name");
+ expect(resolvePluginUiString({ title: "From Bundle" }, "title", { name: "Fallback Name" })).toBe(
+ "From Bundle"
+ );
+ });
+
+ it("loads plugin locale messages from plugin assets", async () => {
+ const apiClient = {
+ async get(url) {
+ if (url.includes("/asset/locales/en.json")) {
+ return { data: { title: "Plugin Title" } };
+ }
+ throw new Error("not found");
+ },
+ };
+ const labels = await loadPluginLabelMap(apiClient, "com.example.plugin", "en", {
+ i18n: { directory: "locales", defaultLocale: "en" },
+ });
+ expect(labels.title).toBe("Plugin Title");
});
});

diff --git a/tests/frontend/pluginManifest.test.js b/tests/frontend/pluginManifest.test.js
index dc800575..282a8947 100644
--- a/tests/frontend/pluginManifest.test.js
+++ b/tests/frontend/pluginManifest.test.js
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it } from "vitest";
-import { validatePluginManifest, manifestPermissionSummary } from "../../meshchatx/src/frontend/js/plugins/pluginManifest.js";
+import {
+ validatePluginManifest,
+ manifestPermissionSummary,
+} from "../../meshchatx/src/frontend/js/plugins/pluginManifest.js";
describe("pluginManifest", () => {
it("validates a minimal manifest", () => {

diff --git a/tests/frontend/registries.test.js b/tests/frontend/registries.test.js
index 4f2b663d..c2f8b18f 100644
--- a/tests/frontend/registries.test.js
+++ b/tests/frontend/registries.test.js
@@ -2,15 +2,27 @@
import { describe, expect, it, beforeEach } from "vitest";
import { createRegistry } from "../../meshchatx/src/frontend/js/registries/registryCore.js";
-import { navRegistry, registerNavItem, unregisterNavItem, listNavItems } from "../../meshchatx/src/frontend/js/registries/navRegistry.js";
+import {
+ navRegistry,
+ registerNavItem,
+ unregisterNavItem,
+ listNavItems,
+} from "../../meshchatx/src/frontend/js/registries/navRegistry.js";
import { toolsRegistry, registerTool, listTools } from "../../meshchatx/src/frontend/js/registries/toolsRegistry.js";
-import { commandRegistry, registerCommand, listCommands } from "../../meshchatx/src/frontend/js/registries/commandRegistry.js";
+import {
+ commandRegistry,
+ registerCommand,
+ listCommands,
+} from "../../meshchatx/src/frontend/js/registries/commandRegistry.js";
import {
settingsSectionRegistry,
registerSettingsSection,
getAllSettingsSectionKeywords,
} from "../../meshchatx/src/frontend/js/registries/settingsSectionRegistry.js";
-import { registerCoreContributions, resetCoreContributionsForTests } from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
+import {
+ registerCoreContributions,
+ resetCoreContributionsForTests,
+} from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
import { CORE_NAV_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreNavEntries.js";
import { CORE_TOOLS_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreToolsEntries.js";


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────